OnboardingStateModel.swift 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  1. import Combine
  2. import DanaKit
  3. import FirebaseCrashlytics
  4. import Foundation
  5. import LoopKit
  6. import MedtrumKit
  7. import MinimedKit
  8. import Observation
  9. import OmnipodKit
  10. import SwiftUI
  11. /// Model that holds the data collected during onboarding.
  12. extension Onboarding {
  13. @Observable final class StateModel: BaseStateModel<Provider> {
  14. @ObservationIgnored @Injected() var fileStorage: FileStorage!
  15. @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
  16. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  17. @ObservationIgnored @Injected() var keychain: Keychain!
  18. @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
  19. @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
  20. @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
  21. @ObservationIgnored @Injected() var apsManager: APSManager!
  22. private let settingsProvider = PickerSettingsProvider.shared
  23. // MARK: - App Diagnostics
  24. var diagnosticsSharingOption: DiagnosticsSharingOption = .full
  25. var hasAcceptedPrivacyPolicy: Bool = false
  26. func syncDiagnosticsOptionFromStorage() {
  27. // Onboarding *is* the consent decision point, so a fresh install
  28. // sees `.full` (truly opt-out). If the user has already picked
  29. // something — e.g. backed out of this step and returned — restore
  30. // their saved selection so they see their current choice.
  31. if PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true {
  32. let crashlytics = PropertyPersistentFlags.shared.diagnosticsSharingEnabled ?? true
  33. let telemetry = PropertyPersistentFlags.shared.telemetryEnabled ?? false
  34. diagnosticsSharingOption = DiagnosticsSharingOption(
  35. crashlyticsEnabled: crashlytics,
  36. telemetryEnabled: telemetry
  37. )
  38. } else {
  39. diagnosticsSharingOption = .full
  40. }
  41. }
  42. func updateDiagnosticsOption(to option: DiagnosticsSharingOption) {
  43. diagnosticsSharingOption = option
  44. PropertyPersistentFlags.shared.diagnosticsSharingEnabled = option.crashlyticsEnabled
  45. PropertyPersistentFlags.shared.telemetryEnabled = option.telemetryEnabled
  46. PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
  47. }
  48. // MARK: - Determine Initial Build State
  49. /// Determines whether the app is in a fresh install state for Trio (new vs. returning/updating user).
  50. ///
  51. /// This check is based on the assumption that a truly clean install will only contain
  52. /// the `logs/` directory and the `preferences.json` file in the app's Documents directory.
  53. ///
  54. /// If this condition is met, the onboarding flow skips the `.returningUser` step and treats
  55. /// the user as new. If more files or directories are found, it is assumed the user is returning.
  56. ///
  57. /// Note: This check is not directly connected to a completed migration. However, if a migration
  58. /// has been triggered (whether successful or not), additional files such as treatment JSONs
  59. /// will exist, which naturally causes this check to return `false`.
  60. var isFreshTrioInstall: Bool {
  61. let fileManager = FileManager.default
  62. guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
  63. return false
  64. }
  65. let expectedLogsFolder = "logs"
  66. let expectedPreferencesFile = OpenAPS.Settings.preferences
  67. do {
  68. let contents = try fileManager.contentsOfDirectory(atPath: documentsURL.path)
  69. // Expect exactly 2 entries: "logs" and the preferences file
  70. guard contents.count == 2 else {
  71. debug(.default, "Trio install is not fresh; returning user.")
  72. return false
  73. }
  74. // Ensure they match exactly
  75. let expectedSet = Set([expectedLogsFolder, expectedPreferencesFile])
  76. let actualSet = Set(contents)
  77. let isFreshInstall = expectedSet == actualSet
  78. debug(.default, "Trio install is fresh; new user.")
  79. return isFreshInstall
  80. } catch {
  81. debug(.default, "Cannot determine Initial Build State. Failed to read documents directory: \(error)")
  82. return false
  83. }
  84. }
  85. // MARK: - Nightscout Setup
  86. var nightscoutSetupOption: NightscoutSetupOption = .noSelection
  87. var nightscoutImportOption: NightscoutImportOption = .noSelection
  88. var nightscoutUrl = ""
  89. var nightscoutSecret = ""
  90. var nightscoutResponseMessage = ""
  91. var isValidNightscoutURL: Bool = false
  92. var isConnectingToNS: Bool = false
  93. var isConnectedToNS: Bool = false
  94. var nightscoutImportError: NightscoutImportError?
  95. var nightscoutImportStatus: ImportStatus = .none
  96. var isUploadEnabled: Bool = true
  97. var uploadGlucose: Bool = true
  98. // MARK: - Units and Pump Omboarding Option
  99. var units: GlucoseUnits = .mgdL
  100. private var selectedPumpOption: PumpOptionForOnboardingUnits?
  101. var pumpOptionForOnboardingUnits: PumpOptionForOnboardingUnits {
  102. get {
  103. // let user edit selection and return user-selection, if present
  104. if let selected = selectedPumpOption {
  105. return selected
  106. }
  107. let defaultOption: PumpOptionForOnboardingUnits
  108. if let pumpManager = apsManager?.pumpManager {
  109. if pumpManager is OmniPumpManager {
  110. defaultOption = .omnipod
  111. } else if pumpManager is MedtrumPumpManager {
  112. defaultOption = .medtrum
  113. } else if pumpManager is DanaKitPumpManager {
  114. defaultOption = .dana
  115. } else if pumpManager is MinimedPumpManager {
  116. defaultOption = .minimed
  117. } else {
  118. defaultOption = .omnipod
  119. }
  120. } else {
  121. defaultOption = .omnipod
  122. }
  123. // cache it so picker can stay in sync
  124. selectedPumpOption = defaultOption
  125. return defaultOption
  126. }
  127. set {
  128. selectedPumpOption = newValue
  129. }
  130. }
  131. // MARK: - Time Values (shared)
  132. let sharedTimeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }.sorted()
  133. // MARK: - Carb Ratio
  134. let carbRatioPickerSetting = PickerSetting(value: 30, step: 0.1, min: 1, max: 50, type: .gram)
  135. var carbRatioItems: [CarbRatioEditor.Item] = []
  136. var initialCarbRatioItems: [CarbRatioEditor.Item] = []
  137. var carbRatioTimeValues: [TimeInterval] { sharedTimeValues }
  138. var carbRatioRateValues: [Decimal] { settingsProvider.generatePickerValues(from: carbRatioPickerSetting, units: units) }
  139. // MARK: - Basal Profile
  140. var basalRatePickerSetting: PickerSetting {
  141. switch selectedPumpOption {
  142. case .dana:
  143. return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 3, type: .insulinUnitPerHour)
  144. case .minimed:
  145. return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 35, type: .insulinUnitPerHour)
  146. case .omnipod:
  147. return PickerSetting(
  148. value: 0.1,
  149. step: 0.05,
  150. min: 0,
  151. max: 30,
  152. type: .insulinUnitPerHour
  153. ) // FIXME: we need to be able to differentiate Eros here due to not allowing 0 basal rates
  154. case .medtrum:
  155. return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour)
  156. case .none:
  157. // same as dash, as that is the fallback
  158. return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour)
  159. }
  160. }
  161. var basalProfileItems: [BasalProfileEditor.Item] = []
  162. var initialBasalProfileItems: [BasalProfileEditor.Item] = []
  163. var basalProfileTimeValues: [TimeInterval] { sharedTimeValues }
  164. var basalProfileRateValues: [Decimal] { settingsProvider.generatePickerValues(from: basalRatePickerSetting, units: units)
  165. }
  166. // MARK: - Insulin Sensitivity Factor (ISF)
  167. var sensitivityPickerSetting = PickerSetting(value: 200, step: 1, min: 9, max: 540, type: .glucose)
  168. var isfItems: [ISFEditor.Item] = []
  169. var initialISFItems: [ISFEditor.Item] = []
  170. var isfTimeValues: [TimeInterval] { sharedTimeValues }
  171. var isfRateValues: [Decimal] { settingsProvider.generatePickerValues(from: sensitivityPickerSetting, units: units) }
  172. // MARK: - Glucose Targets
  173. let letTargetPickerSetting = PickerSetting(value: 110, step: 1, min: 72, max: 180, type: .glucose)
  174. var targetItems: [TargetsEditor.Item] = []
  175. var initialTargetItems: [TargetsEditor.Item] = []
  176. var targetTimeValues: [TimeInterval] { sharedTimeValues }
  177. var targetRateValues: [Decimal] { settingsProvider.generatePickerValues(from: letTargetPickerSetting, units: units) }
  178. // MARK: - Delivery Limit Defaults
  179. var maxBolus: Decimal = 10
  180. var maxBasal: Decimal = 2
  181. var maxIOB: Decimal = 0
  182. var maxCOB: Decimal = 120
  183. var minimumSafetyThreshold: Decimal = 60
  184. // MARK: - Algorithm Settings Defaults
  185. // Autosens Settings
  186. var autosensMin: Decimal = 0.7
  187. var autosensMax: Decimal = 1.2
  188. var rewindResetsAutosens: Bool = true
  189. var filteredAutosensSettingsSubsteps: [AutosensSettingsSubstep] {
  190. if pumpOptionForOnboardingUnits == .minimed || pumpOptionForOnboardingUnits == .dana {
  191. return AutosensSettingsSubstep.allCases
  192. } else {
  193. return [AutosensSettingsSubstep.autosensMin, AutosensSettingsSubstep.autosensMax]
  194. }
  195. }
  196. // SMB Settings
  197. var enableSMBAlways: Bool = false
  198. var enableSMBWithCOB: Bool = false
  199. var enableSMBWithTempTarget: Bool = false
  200. var enableSMBAfterCarbs: Bool = false
  201. var enableSMBWithHighGlucoseTarget: Bool = false
  202. var highGlucoseTarget: Decimal = 110
  203. var allowSMBWithHighTempTarget: Bool = false
  204. var enableUAM: Bool = false
  205. var maxSMBMinutes: Decimal = 30
  206. var maxUAMMinutes: Decimal = 30
  207. var maxDeltaGlucoseThreshold: Decimal = 0.2
  208. // Target Behavior
  209. var highTempTargetRaisesSensitivity: Bool = false
  210. var lowTempTargetLowersSensitivity: Bool = false
  211. var sensitivityRaisesTarget: Bool = false
  212. var resistanceLowersTarget: Bool = false
  213. var halfBasalTarget: Decimal = 160
  214. // MARK: - Permission Requests
  215. var hasNotificationsGranted = false
  216. var shouldDisplayCustomNotificationAlert: Bool = false
  217. var shouldDisplayBluetoothRequestAlert: Bool = false
  218. var hasBluetoothGranted = false
  219. // MARK: - Subscribe
  220. override func subscribe() {
  221. // Keychain items are not removed, even after uninstalling the app. Attempt to read them initially.
  222. nightscoutUrl = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) ?? ""
  223. nightscoutSecret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) ?? ""
  224. isConnectedToNS = false
  225. isConnectingToNS = false
  226. isValidNightscoutURL = false
  227. if !isFreshTrioInstall {
  228. // Attempt to fetch existing units, therapy settings and delivery limits from file
  229. units = settingsManager.settings.units
  230. fetchExistingTherapySettingsFromFile()
  231. fetchExistingDeliveryLimtisFromFile()
  232. }
  233. }
  234. // MARK: - Helpers
  235. /// Finds the index of the closest `Decimal` value in the given array.
  236. /// - Parameters:
  237. /// - value: The value to match.
  238. /// - array: The array to search in.
  239. /// - Returns: Closest index in array.
  240. func closestIndex(for value: Decimal, in array: [Decimal]) -> Int {
  241. array.enumerated().min(by: {
  242. abs($0.element - value) < abs($1.element - value)
  243. })?.offset ?? 0
  244. }
  245. /// Finds the index of the closest `TimeInterval` value in the given array.
  246. /// - Parameters:
  247. /// - value: The time value to match.
  248. /// - array: The array to search in.
  249. /// - Returns: Closest index in array.
  250. func closestIndex(for value: TimeInterval, in array: [TimeInterval]) -> Int {
  251. array.enumerated().min(by: {
  252. abs($0.element - value) < abs($1.element - value)
  253. })?.offset ?? 0
  254. }
  255. /// A date formatter for time strings used in saved settings.
  256. private var timeFormatter: DateFormatter {
  257. let formatter = DateFormatter()
  258. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  259. formatter.dateFormat = "HH:mm:ss"
  260. return formatter
  261. }
  262. /// Remaps therapy items affected by a glucose unit change (mg/dL vs mmol/L).
  263. ///
  264. /// This function updates glucose target and insulin sensitivity (ISF) items to use the closest valid index
  265. /// from the newly available rate arrays, preserving the original value intent.
  266. ///
  267. /// Call this after the user changes the unit selection.
  268. ///
  269. /// See also: `UnitSelectionStepView` `.onChange()` handlers.
  270. func remapTherapyItemsForChangedUnits() {
  271. // Targets
  272. targetItems = targetItems.map { item in
  273. let newLowIndex = closestIndex(for: targetRateValues[item.lowIndex], in: targetRateValues)
  274. let newTimeIndex = closestIndex(for: targetTimeValues[item.timeIndex], in: targetTimeValues)
  275. return TargetsEditor.Item(lowIndex: newLowIndex, highIndex: newLowIndex, timeIndex: newTimeIndex)
  276. }
  277. // ISF
  278. isfItems = isfItems.map { item in
  279. let newRateIndex = closestIndex(for: isfRateValues[item.rateIndex], in: isfRateValues)
  280. let newTimeIndex = closestIndex(for: isfTimeValues[item.timeIndex], in: isfTimeValues)
  281. return ISFEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
  282. }
  283. }
  284. /// Remaps therapy items affected by a pump model change.
  285. ///
  286. /// Updates basal profile items to use the closest valid index from
  287. /// the updated basal rate and time arrays, preserving the user's settings
  288. /// as closely as possible when switching between pump models.
  289. ///
  290. /// If an imported item's `rateIndex` or `timeIndex` exceeds the bounds of the
  291. /// current pump's allowed values, it is clamped to the last valid index to avoid
  292. /// crashes and preserve data integrity. A debug message is logged if clamping occurs.
  293. ///
  294. /// Call this after the user selects a new pump model.
  295. ///
  296. /// See also: `UnitSelectionStepView` `.onChange()` handlers.
  297. func remapTherapyItemsForChangedPumpModel() {
  298. let maxValidRateIndex = max(basalProfileRateValues.count - 1, 0)
  299. let maxValidTimeIndex = max(basalProfileTimeValues.count - 1, 0)
  300. basalProfileItems = basalProfileItems.map { item in
  301. let safeRateIndex = min(item.rateIndex, maxValidRateIndex)
  302. let safeTimeIndex = min(item.timeIndex, maxValidTimeIndex)
  303. let originalRate = basalProfileRateValues[safeRateIndex]
  304. let originalTime = basalProfileTimeValues[safeTimeIndex]
  305. let newRateIndex = closestIndex(for: originalRate, in: basalProfileRateValues)
  306. let newTimeIndex = closestIndex(for: originalTime, in: basalProfileTimeValues)
  307. if safeRateIndex != item.rateIndex {
  308. debug(.default, "⚠️ rateIndex \(item.rateIndex) out of bounds; clamped to \(safeRateIndex)")
  309. }
  310. return BasalProfileEditor.Item(rateIndex: newRateIndex, timeIndex: newTimeIndex)
  311. }
  312. }
  313. // MARK: - Fetch existing therapy settings from file
  314. /// Loads existing therapy settings from the provider and maps them into UI editor items.
  315. ///
  316. /// This function processes therapy-related configurations (glucose targets, basal rates,
  317. /// carb ratios, and insulin sensitivity factors) stored in file-backed models from the provider.
  318. /// It calculates the closest matching indices for time and rate values to map them to corresponding
  319. /// `Editor.Item` models for use in the UI.
  320. ///
  321. /// - Populates:
  322. /// - `targetItems` and `initialTargetItems` with glucose target entries.
  323. /// - `basalProfileItems` and `initialBasalProfileItems` with basal rate entries.
  324. /// - `carbRatioItems` and `initialCarbRatioItems` with carbohydrate ratio entries.
  325. /// - `isfItems` and `initialISFItems` with insulin sensitivity factor entries.
  326. func fetchExistingTherapySettingsFromFile() {
  327. targetItems = provider.glucoseTargetsOnFile.targets.map { value in
  328. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: targetTimeValues)
  329. let lowIndex = closestIndex(for: value.low, in: targetRateValues)
  330. let highIndex = closestIndex(for: value.high, in: targetRateValues)
  331. return TargetsEditor.Item(lowIndex: lowIndex, highIndex: highIndex, timeIndex: timeIndex)
  332. }
  333. initialTargetItems = targetItems
  334. .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
  335. basalProfileItems = provider.basalProfileOnFile.map { value in
  336. let timeIndex = closestIndex(for: TimeInterval(Double(value.minutes * 60)), in: basalProfileTimeValues)
  337. let rateIndex = closestIndex(for: value.rate, in: basalProfileRateValues)
  338. return BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  339. }
  340. initialBasalProfileItems = basalProfileItems
  341. .map { BasalProfileEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  342. carbRatioItems = provider.carbRatiosOnFile.schedule.map { value in
  343. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: carbRatioTimeValues)
  344. let rateIndex = closestIndex(for: value.ratio, in: carbRatioRateValues)
  345. return CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  346. }
  347. initialCarbRatioItems = carbRatioItems.map { CarbRatioEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  348. isfItems = provider.isfOnFile.sensitivities.map { value in
  349. let timeIndex = closestIndex(for: TimeInterval(Double(value.offset * 60)), in: isfTimeValues)
  350. let rateIndex = closestIndex(for: value.sensitivity, in: isfRateValues)
  351. return ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  352. }
  353. initialISFItems = isfItems.map { ISFEditor.Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) }
  354. }
  355. /// Loads delivery limit settings (Units, Max IOB, Max COB, Max Bolus, Max Basal) from the provider.
  356. ///
  357. /// Retrieves pump-related safety and delivery limits from both the provider's
  358. /// file-backed pump settings and app-specific preferences. These values are used
  359. /// to pre-fill the delivery limits editor in the onboarding or settings UI.
  360. ///
  361. /// - Populates:
  362. /// - `maxBolus` and `maxBasal` from file-based pump settings.
  363. /// - `maxIOB`, `maxCOB`, and `minimumSafetyThreshold` from app preferences.
  364. /// - `units` from app settings.
  365. func fetchExistingDeliveryLimtisFromFile() {
  366. let pumpSettingsFromFile = provider.pumpSettingsFromFile
  367. let providedSettings = settingsProvider.settings
  368. if let pumpSettingsFromFile = pumpSettingsFromFile {
  369. maxBolus = pumpSettingsFromFile.maxBolus.clamp(to: providedSettings.maxBolus)
  370. maxBasal = pumpSettingsFromFile.maxBasal.clamp(to: providedSettings.maxBasal)
  371. }
  372. let preferences = settingsManager.preferences
  373. maxIOB = preferences.maxIOB.clamp(to: providedSettings.maxIOB)
  374. maxCOB = preferences.maxCOB.clamp(to: providedSettings.maxCOB)
  375. minimumSafetyThreshold = preferences.threshold_setting
  376. }
  377. // MARK: - Get Therapy Items
  378. /// Converts ISF editor items to a list of `TherapySettingItem`.
  379. /// - Returns: Sorted list of therapy setting items based on ISF.
  380. func getISFTherapyItems() -> [TherapySettingItem] {
  381. getTherapyItems(from: isfItems, rateValues: isfRateValues, timeValues: isfTimeValues)
  382. }
  383. /// Converts basal profile editor items to a list of `TherapySettingItem`.
  384. /// - Returns: Sorted list of therapy setting items based on basal rates.
  385. func getBasalTherapyItems() -> [TherapySettingItem] {
  386. getTherapyItems(
  387. from: basalProfileItems,
  388. rateValues: basalProfileRateValues,
  389. timeValues: basalProfileTimeValues
  390. )
  391. }
  392. /// Converts carb ratio editor items to a list of `TherapySettingItem`.
  393. /// - Returns: Sorted list of therapy setting items based on carb ratios.
  394. func getCarbRatioTherapyItems() -> [TherapySettingItem] {
  395. getTherapyItems(from: carbRatioItems, rateValues: carbRatioRateValues, timeValues: carbRatioTimeValues)
  396. }
  397. /// Converts glucose target editor items to a list of `TherapySettingItem`.
  398. /// - Returns: Sorted list of therapy setting items based on glucose targets.
  399. func getTargetTherapyItems() -> [TherapySettingItem] {
  400. targetItems.map {
  401. TherapySettingItem(
  402. time: targetTimeValues[$0.timeIndex],
  403. value: targetRateValues[$0.lowIndex]
  404. )
  405. }.sorted { $0.time < $1.time }
  406. }
  407. /// Generic helper to convert any type of editor item into therapy setting items.
  408. /// - Parameters:
  409. /// - items: An array of items conforming to `TherapyItemConvertible`.
  410. /// - rateValues: The rate values to be used.
  411. /// - timeValues: The time values to be used.
  412. /// - Returns: A sorted array of `TherapySettingItem`.
  413. private func getTherapyItems<T: TherapyItemConvertible>(
  414. from items: [T],
  415. rateValues: [Decimal],
  416. timeValues: [TimeInterval]
  417. ) -> [TherapySettingItem] {
  418. items.map {
  419. TherapySettingItem(
  420. time: timeValues[$0.timeIndex],
  421. value: rateValues[$0.rateIndex]
  422. )
  423. }.sorted { $0.time < $1.time }
  424. }
  425. // MARK: - Unified Update Methods
  426. /// Updates the ISF editor items based on the provided therapy setting items.
  427. /// - Parameter therapyItems: The list of therapy items to update from.
  428. func updateISF(from therapyItems: [TherapySettingItem]) {
  429. isfItems = therapyItems.map {
  430. ISFEditor.Item(
  431. rateIndex: closestIndex(for: $0.value, in: isfRateValues),
  432. timeIndex: closestIndex(for: $0.time, in: isfTimeValues)
  433. )
  434. }.sorted { $0.timeIndex < $1.timeIndex }
  435. }
  436. /// Updates the basal rate editor items based on the provided therapy setting items.
  437. /// - Parameter therapyItems: The list of therapy items to update from.
  438. func updateBasal(from therapyItems: [TherapySettingItem]) {
  439. basalProfileItems = therapyItems.map {
  440. BasalProfileEditor.Item(
  441. rateIndex: closestIndex(for: $0.value, in: basalProfileRateValues),
  442. timeIndex: closestIndex(for: $0.time, in: basalProfileTimeValues)
  443. )
  444. }.sorted { $0.timeIndex < $1.timeIndex }
  445. }
  446. /// Updates the carb ratio editor items based on the provided therapy setting items.
  447. /// - Parameter therapyItems: The list of therapy items to update from.
  448. func updateCarbRatio(from therapyItems: [TherapySettingItem]) {
  449. carbRatioItems = therapyItems.map {
  450. CarbRatioEditor.Item(
  451. rateIndex: closestIndex(for: $0.value, in: carbRatioRateValues),
  452. timeIndex: closestIndex(for: $0.time, in: carbRatioTimeValues)
  453. )
  454. }.sorted { $0.timeIndex < $1.timeIndex }
  455. }
  456. /// Updates the glucose target editor items based on the provided therapy setting items.
  457. /// - Parameter therapyItems: The list of therapy items to update from.
  458. func updateTargets(from therapyItems: [TherapySettingItem]) {
  459. targetItems = therapyItems.map {
  460. let rateIndex = closestIndex(for: $0.value, in: targetRateValues)
  461. let timeIndex = closestIndex(for: $0.time, in: targetTimeValues)
  462. return TargetsEditor.Item(
  463. lowIndex: rateIndex,
  464. highIndex: rateIndex,
  465. timeIndex: timeIndex
  466. )
  467. }.sorted { $0.timeIndex < $1.timeIndex }
  468. }
  469. // MARK: - Add Initials
  470. /// Adds a default ISF editor item at 00:00 with a standard sensitivity value.
  471. func addInitialISF() {
  472. addInitialItem(
  473. defaultValue: 200,
  474. rateValues: isfRateValues,
  475. assign: { isfItems = $0 },
  476. makeItem: ISFEditor.Item.init
  477. )
  478. }
  479. /// Adds a default basal rate editor item at 00:00 with a typical rate value.
  480. func addInitialBasalRate() {
  481. addInitialItem(
  482. defaultValue: 0.1,
  483. rateValues: basalProfileRateValues,
  484. assign: { basalProfileItems = $0 },
  485. makeItem: BasalProfileEditor.Item.init
  486. )
  487. }
  488. /// Adds a default carb ratio editor item at 00:00 with a standard ratio.
  489. func addInitialCarbRatio() {
  490. addInitialItem(
  491. defaultValue: 30,
  492. rateValues: carbRatioRateValues,
  493. assign: { carbRatioItems = $0 },
  494. makeItem: CarbRatioEditor.Item.init
  495. )
  496. }
  497. /// Adds a default glucose target item at 00:00 with a typical target value.
  498. func addInitialTarget() {
  499. let timeIndex = 0
  500. let rateIndex = closestIndex(for: 110, in: targetRateValues)
  501. targetItems = [TargetsEditor.Item(lowIndex: rateIndex, highIndex: rateIndex, timeIndex: timeIndex)]
  502. }
  503. /// Adds an initial therapy setting item for a given editor item type.
  504. /// - Parameters:
  505. /// - defaultValue: The expected default value to use.
  506. /// - rateValues: The array of rate values for the item.
  507. /// - assign: A closure that assigns the newly created array to the correct property.
  508. private func addInitialItem<ItemType>(
  509. defaultValue: Decimal,
  510. rateValues: [Decimal],
  511. assign: ([ItemType]) -> Void,
  512. makeItem: (Int, Int) -> ItemType
  513. ) {
  514. let timeIndex = 0
  515. let rateIndex = closestIndex(for: defaultValue, in: rateValues)
  516. assign([makeItem(rateIndex, timeIndex)])
  517. }
  518. // MARK: - Validate
  519. /// Removes duplicate entries from `carbRatioItems`, ensures sorting by time index,
  520. /// and forces the first entry to start at 00:00 (timeIndex 0).
  521. func validateCarbRatios() {
  522. carbRatioItems = validated(items: carbRatioItems, timeIndexKeyPath: \.timeIndex)
  523. }
  524. /// Removes duplicate entries from `basalProfileItems`, ensures sorting by time index,
  525. /// and forces the first entry to start at 00:00 (timeIndex 0).
  526. func validateBasal() {
  527. basalProfileItems = validated(items: basalProfileItems, timeIndexKeyPath: \.timeIndex)
  528. }
  529. /// Removes duplicate entries from `isfItems`, ensures sorting by time index,
  530. /// and forces the first entry to start at 00:00 (timeIndex 0).
  531. func validateISF() {
  532. isfItems = validated(items: isfItems, timeIndexKeyPath: \.timeIndex)
  533. }
  534. /// Removes duplicate entries from `targetItems`, ensures sorting by time index,
  535. /// and forces the first entry to start at 00:00 (timeIndex 0).
  536. func validateTarget() {
  537. targetItems = validated(items: targetItems, timeIndexKeyPath: \.timeIndex)
  538. }
  539. /// Removes duplicates, sorts by time, and ensures the first entry starts at 00:00.
  540. /// - Parameters:
  541. /// - items: The list of items to validate.
  542. /// - timeIndexKeyPath: A writable key path to the timeIndex property.
  543. /// - Returns: A validated and sorted list of items with the first entry at 00:00.
  544. private func validated<T: Hashable>(items: [T], timeIndexKeyPath: WritableKeyPath<T, Int>) -> [T] {
  545. var result = Array(Set(items)).sorted { $0[keyPath: timeIndexKeyPath] < $1[keyPath: timeIndexKeyPath] }
  546. if !result.isEmpty, result[0][keyPath: timeIndexKeyPath] != 0 {
  547. result[0][keyPath: timeIndexKeyPath] = 0
  548. }
  549. return result
  550. }
  551. // MARK: - Save
  552. /// Saves the carb ratio items to file storage and sets them as initial values.
  553. func saveCarbRatios() {
  554. let schedule = carbRatioItems.map { item in
  555. let time = timeFormatter.string(from: Date(timeIntervalSince1970: carbRatioTimeValues[item.timeIndex]))
  556. let offset = Int(carbRatioTimeValues[item.timeIndex] / 60)
  557. let value = carbRatioRateValues[item.rateIndex]
  558. return CarbRatioEntry(start: time, offset: offset, ratio: value)
  559. }
  560. fileStorage.save(CarbRatios(units: .grams, schedule: schedule), as: OpenAPS.Settings.carbRatios)
  561. initialCarbRatioItems = carbRatioItems
  562. }
  563. /// Saves the basal profile items to file storage and sets them as initial values.
  564. func saveBasalProfile() {
  565. let profile = basalProfileItems.map { item in
  566. let time = timeFormatter.string(from: Date(timeIntervalSince1970: basalProfileTimeValues[item.timeIndex]))
  567. let offset = Int(basalProfileTimeValues[item.timeIndex] / 60)
  568. let rate = basalProfileRateValues[item.rateIndex]
  569. return BasalProfileEntry(start: time, minutes: offset, rate: rate)
  570. }
  571. fileStorage.save(profile, as: OpenAPS.Settings.basalProfile)
  572. initialBasalProfileItems = basalProfileItems
  573. }
  574. /// Saves the insulin sensitivity (ISF) items to file storage and sets them as initial values.
  575. func saveISFValues() {
  576. let sensitivities = isfItems.map { item in
  577. let time = timeFormatter.string(from: Date(timeIntervalSince1970: isfTimeValues[item.timeIndex]))
  578. let offset = Int(isfTimeValues[item.timeIndex] / 60)
  579. let value = isfRateValues[item.rateIndex]
  580. return InsulinSensitivityEntry(sensitivity: value, offset: offset, start: time)
  581. }
  582. let profile = InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: sensitivities)
  583. fileStorage.save(profile, as: OpenAPS.Settings.insulinSensitivities)
  584. initialISFItems = isfItems
  585. }
  586. /// Saves the glucose target items to file storage and sets them as initial values.
  587. func saveTargets() {
  588. let targets = targetItems.map { item in
  589. let time = timeFormatter.string(from: Date(timeIntervalSince1970: targetTimeValues[item.timeIndex]))
  590. let offset = Int(targetTimeValues[item.timeIndex] / 60)
  591. let value = targetRateValues[item.lowIndex]
  592. return BGTargetEntry(low: value, high: value, start: time, offset: offset)
  593. }
  594. let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  595. fileStorage.save(profile, as: OpenAPS.Settings.bgTargets)
  596. initialTargetItems = targetItems
  597. }
  598. /// Persists all onboarding data by applying settings and saving therapy values.
  599. func saveOnboardingData() {
  600. applyDiagnostics()
  601. applyToSettings()
  602. applyToPreferences()
  603. applyToPumpSettings()
  604. saveTargets()
  605. saveBasalProfile()
  606. saveCarbRatios()
  607. saveISFValues()
  608. }
  609. /// Persists the current diagnostics sharing option and applies it to Crashlytics + telemetry.
  610. func applyDiagnostics() {
  611. PropertyPersistentFlags.shared.diagnosticsSharingEnabled = diagnosticsSharingOption.crashlyticsEnabled
  612. PropertyPersistentFlags.shared.telemetryEnabled = diagnosticsSharingOption.telemetryEnabled
  613. PropertyPersistentFlags.shared.telemetryConsentDecisionMade = true
  614. Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(diagnosticsSharingOption.crashlyticsEnabled)
  615. if diagnosticsSharingOption.telemetryEnabled {
  616. TelemetryClient.shared.scheduleRecurring()
  617. Task.detached { await TelemetryClient.shared.maybeSend() }
  618. }
  619. }
  620. /// Applies the selected glucose units to the app's settings.
  621. func applyToSettings() {
  622. var settingsCopy = settingsManager.settings
  623. settingsCopy.units = units
  624. if nightscoutSetupOption == .setupNightscout {
  625. settingsCopy.isUploadEnabled = isUploadEnabled
  626. settingsCopy.uploadGlucose = uploadGlucose
  627. }
  628. // ensure existing values cannot exceed new guardrails
  629. if !isFreshTrioInstall {
  630. let providedSettings = settingsProvider.settings
  631. settingsCopy.lowGlucose = settingsCopy.lowGlucose.clamp(to: providedSettings.lowGlucose)
  632. settingsCopy.highGlucose = settingsCopy.highGlucose.clamp(to: providedSettings.highGlucose)
  633. settingsCopy.carbsRequiredThreshold = settingsCopy.carbsRequiredThreshold
  634. .clamp(to: providedSettings.carbsRequiredThreshold)
  635. settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor
  636. .clamp(to: providedSettings.individualAdjustmentFactor)
  637. settingsCopy.minuteInterval = settingsCopy.minuteInterval.clamp(to: providedSettings.minuteInterval)
  638. settingsCopy.delay = settingsCopy.delay.clamp(to: providedSettings.delay)
  639. settingsCopy.high = settingsCopy.high.clamp(to: providedSettings.high)
  640. settingsCopy.low = settingsCopy.low.clamp(to: providedSettings.low)
  641. settingsCopy.maxCarbs = settingsCopy.maxCarbs.clamp(to: providedSettings.maxCarbs)
  642. settingsCopy.maxFat = settingsCopy.maxFat.clamp(to: providedSettings.maxFat)
  643. settingsCopy.maxProtein = settingsCopy.maxProtein.clamp(to: providedSettings.maxProtein)
  644. settingsCopy.overrideFactor = settingsCopy.overrideFactor.clamp(to: providedSettings.overrideFactor)
  645. settingsCopy.fattyMealFactor = settingsCopy.fattyMealFactor.clamp(to: providedSettings.fattyMealFactor)
  646. settingsCopy.sweetMealFactor = settingsCopy.sweetMealFactor.clamp(to: providedSettings.sweetMealFactor)
  647. }
  648. settingsManager.settings = settingsCopy
  649. }
  650. /// Applies the selected delivery preferences to the app's settings.
  651. func applyToPreferences() {
  652. var preferences = Preferences()
  653. // delivery limits (those that are preference-bound, not pump-settings-bound
  654. preferences.maxIOB = maxIOB
  655. preferences.maxCOB = maxCOB
  656. preferences.threshold_setting = minimumSafetyThreshold
  657. // autosens
  658. preferences.autosensMin = autosensMin
  659. preferences.autosensMax = autosensMax
  660. preferences.rewindResetsAutosens = rewindResetsAutosens
  661. // smb settings
  662. preferences.enableSMBAlways = enableSMBAlways
  663. preferences.enableSMBWithCOB = enableSMBWithCOB
  664. preferences.enableSMBWithTemptarget = enableSMBWithTempTarget
  665. preferences.enableSMBAfterCarbs = enableSMBAfterCarbs
  666. preferences.enableSMB_high_bg = enableSMBWithHighGlucoseTarget
  667. preferences.enableSMB_high_bg_target = highGlucoseTarget
  668. preferences.allowSMBWithHighTemptarget = allowSMBWithHighTempTarget
  669. preferences.enableUAM = enableUAM
  670. preferences.maxSMBBasalMinutes = maxSMBMinutes
  671. preferences.maxUAMSMBBasalMinutes = maxUAMMinutes
  672. preferences.maxDeltaBGthreshold = maxDeltaGlucoseThreshold
  673. // target behavior
  674. preferences.highTemptargetRaisesSensitivity = highTempTargetRaisesSensitivity
  675. preferences.lowTemptargetLowersSensitivity = lowTempTargetLowersSensitivity
  676. preferences.sensitivityRaisesTarget = sensitivityRaisesTarget
  677. preferences.resistanceLowersTarget = resistanceLowersTarget
  678. preferences.halfBasalExerciseTarget = halfBasalTarget
  679. // default suspendZeroesIOB to true
  680. if !preferences.suspendZerosIOB {
  681. preferences.suspendZerosIOB = true
  682. }
  683. // ensure correct bolusIncrement is set, if user is onboarding with paired pump
  684. if let pumpManager = apsManager?.pumpManager {
  685. let bolusIncrement = Decimal(
  686. pumpManager.supportedBolusVolumes.first ??
  687. Double(
  688. settingsManager.preferences
  689. .bolusIncrement
  690. )
  691. )
  692. preferences.bolusIncrement = bolusIncrement > 0 ? bolusIncrement : 0.1
  693. }
  694. settingsManager.preferences = preferences
  695. }
  696. /// Saves pump delivery limits to persistent storage and broadcasts changes.
  697. func applyToPumpSettings() {
  698. let defaultDIA = settingsProvider.settings.dia.value
  699. let pumpSettings = PumpSettings(insulinActionCurve: defaultDIA, maxBolus: maxBolus, maxBasal: maxBasal)
  700. fileStorage.save(pumpSettings, as: OpenAPS.Settings.settings)
  701. }
  702. }
  703. }
  704. // MARK: - Protocol (optional) to unify type mapping
  705. protocol TherapyItemConvertible {
  706. var rateIndex: Int { get }
  707. var timeIndex: Int { get }
  708. }
  709. extension ISFEditor.Item: TherapyItemConvertible {}
  710. extension CarbRatioEditor.Item: TherapyItemConvertible {}
  711. extension BasalProfileEditor.Item: TherapyItemConvertible {}